iT邦幫忙

2026 iThome 鐵人賽

DAY 20
0
Kubernetes

凌晨四點,女友帶著 GPU 來我家學習 Kubernetes:打造 K8s AI Infra 的 30 夜系列 第 20 篇

【Day 20】HPA 與 KEDA:Kubernetes 上誰在指揮 Pod 擴縮

  • 分享至 

  • xImage
  •  

Day 18 的最後,我們把 vLLM 的冷啟動從 526.9 秒壓到 41.8 秒。冷啟動指的是 vLLM 被啟動之後,要先把模型權重讀進 GPU、做完編譯準備,才能回答第一個請求。這段時間程式已經在跑,但還不能接請求。

這 41.8 秒由誰來等,取決於發生的時機。副本數固定不動的話,它落在部署的那一刻,我們自己盯著它起來,確認好了才把網址給別人。副本數會隨流量變動就不一樣了:半夜沒人用縮到剩一個,早上流量進來才長出新的副本,而這個新副本的 41.8 秒,是第一個打進來的使用者坐在螢幕前等的。

所以壓這個數字的用途在今天:誰決定要開幾個副本。


HPA

HorizontalPodAutoscaler 會自動更新一個工作負載資源(例如 Deployment 或 StatefulSet),讓容量跟上需求。

這裡的水平指的是負載變重時多部署幾個 Pod,對照的是垂直:給已經在跑的 Pod 更多資源,例如記憶體或 CPU。HPA 只改副本數,不改 Pod 本身的規格。

指標來源有五種

HPA 的 metrics 欄位放的是用來算出目標副本數的規格,型別有五種:

型別 內容
Resource Kubernetes 本身知道的資源指標,例如 requests 和 limits 裡寫的那些,描述每一個 Pod
ContainerResource 同上,但描述的是每個 Pod 裡的單一容器
Pods 描述擴縮目標底下每一個 Pod 的指標
Object 描述單一 Kubernetes 物件的指標,例如一個 Ingress 物件上的每秒命中數
External 跟任何 Kubernetes 物件都沒有關聯的全域指標

vLLM 吐出來的 vllm:num_requests_waiting 不屬於 Kubernetes 知道的資源,所以不會走 Resource。

HPA 的副本數下限是 1

minReplicas 是 HPA 能縮到的下限,預設值是 1 個 Pod。

所以 HPA 可以把副本數從 10 降到 1,但不會降到 0。要讓副本數變成 0,得靠 HPA 以外的東西,下一節的 KEDA 做的就是這件事。

scaleUp 和 scaleDown 的預設值不對稱

HPA 的 behavior 分成 scaleUp 和 scaleDown 兩邊:

預設行為
scaleUp 每 15 秒最多增加 4 個 Pod,或者每 15 秒翻一倍。算出來的建議值直接採用
scaleDown stabilizationWindowSeconds 預設 300,縮容前會回頭看過去 300 秒算過的建議值,取其中最大的那一個。縮容只有一條政策,允許一次移除當前正在跑的全部副本

擴容直接採用當下算出的建議值,縮容要回頭看五分鐘。而縮容一旦決定要降,是一步降到位,因為那條政策允許一次移除全部副本。實驗三會看到這兩件事合起來的結果。

指標選哪一個

HPA 的行為完全由這個指標決定,指標選錯,後面的設定再精細也沒用。

GPU 使用率是最直覺的選擇,但它量不出 GPU 活著的時候做了多少事,所以很難把延遲和吞吐這類推論指標對應到一個使用率門檻。

實務上用的是請求數。以 vLLM production-stack 為例,直接寫 HPA 的話長這樣:

apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: vllm-hpa
  namespace: default
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: vllm-llama3-deployment-vllm
  minReplicas: 1
  maxReplicas: 2
  metrics:
  - type: Object
    object:
      metric:
        name: vllm_num_requests_waiting
      describedObject:
        apiVersion: v1
        kind: Namespace
        name: default
      target:
        type: Value
        value: 1

type: Object 搭 target.type: Value,指標是 vllm_num_requests_waiting,門檻是 1。


KEDA

KEDA 是一個輕量的開源元件,它擴充 Kubernetes 的擴縮能力,讓任何容器工作負載都能依事件擴縮,依據可以是 Kubernetes 資源或外部指標。

用 KEDA 的時候要自己寫的是一個叫 ScaledObject 的自訂資源,裡面指定要擴縮哪一個資源、以及依據什麼 trigger。

KEDA 底下實際在縮放的還是 HPA

KEDA 會自己建一個 HPA 出來,名字預設是 keda-hpa-{ScaledObject 名稱}。

所以裝了 KEDA 之後,叢集裡真正在改副本數的仍然是一個 HPA,只是這個 HPA 由 KEDA 建立和填寫,不需要我們自己寫。上一節那些 HPA 的行為預設值,在 KEDA 底下一條都沒有消失。

HPA 不會自己連 Prometheus,它只會向 Kubernetes 的 API 問指標。所以中間要有一個元件,把 Prometheus 的數字註冊成 Kubernetes 的指標 API,而用 KEDA 的時候,做這件事的就是 KEDA。實驗一會看到它的實際樣子。

擴縮被切成兩段

KEDA 把擴縮分成 activation 和 scaling 兩段,兩段由不同的東西決定:

區間 KEDA 的叫法 誰決定
0 和 1 之間 activation KEDA operator
1 到 n scaling KEDA 建的那個 HPA

所以 HPA 的 minReplicas 預設 1 並沒有被繞過,1 以下那一段不是 HPA 在管。

ScaledObject

ScaledObject 指定的是要讓 KEDA 依 trigger 擴縮的那個 Deployment 或 StatefulSet,而 KEDA 在背後監看事件來源、把資料交給 Kubernetes 和 HPA,再由 HPA 去縮放資源。

欄位如下:

欄位 預設 內容
scaleTargetRef 必填 擴縮目標,預設是同一個 namespace 裡的 Deployment
pollingInterval 30 秒 檢查每一個 trigger 的間隔。副本數為零時,這個值控制 KEDA 多久去問一次指標來源
cooldownPeriod 300 秒 最後一次 trigger 回報作用中之後,要等多久才把資源縮回 0
minReplicaCount 0 KEDA 會把資源縮到的最小副本數
maxReplicaCount 100 目標資源的最大副本數

cooldownPeriod 和 HPA 的 stabilizationWindowSeconds 預設值都是 300 秒,但前者管 1 到 0,後者管 n 到 m。

Prometheus trigger

要讀 vLLM 的指標,trigger 型別選 prometheus:

欄位 內容
serverAddress Prometheus 伺服器的位址
query 要執行的查詢,回傳值必須是單一元素的向量或純量
threshold 開始擴縮的值
activationThreshold 啟用 scaler 的目標值,預設 0

threshold 和 activationThreshold 分別對應上面那兩段:前者管 1 到 n 要幾個,後者管要不要從 0 開始動。

參考別人用的指標

vLLM production-stack 把裝 KEDA 和自己寫 HPA 兩種做法都附了,所以它在下面的表格裡出現兩次。

來源 擴縮器 指標 門檻
vLLM production-stack KEDA vllm:num_requests_waiting 5
vLLM production-stack HPA vllm_num_requests_waiting 1
KServe KEDA vllm:num_requests_running 2

三份設定沒有一份用 GPU 使用率。

兩份看的是佇列深度,也就是還在伺服器佇列裡等著被處理的請求數;一份看正在跑的請求數,也就是當下的 batch 大小。

這兩種指標對應的是不同的目標。佇列深度管的是吞吐和成本,前提是延遲目標落在模型伺服器最大 batch 的吞吐範圍內;而延遲敏感、佇列反應又不夠快的工作負載才選 batch 大小。


實驗環境

今天量的是控制器行為,不是排程也不是跨節點,所以單節點就夠,也不需要 GPU。在一台沒有 GPU 的機器上用 kind 開一個單節點叢集。

被擴縮的目標不是真的 vLLM,而是一個 pause 映像的 Deployment,Pod 秒起;指標也不是真的 vLLM 吐的,而是一個自己寫的服務,佇列深度由我用 HTTP 灌進去。換句話說,下面三個實驗量到的是 KEDA 和 HPA 的控制器行為,不是 vLLM 的行為。

實驗 做什麼 看什麼
一 KEDA 和 HPA 的關係 建好 ScaledObject 之後看叢集裡多了什麼 多出來的 HPA、它的 ownerReferences、它讀的 metric,以及指標是從哪個 API 來的
二 副本數的算式 把佇列深度依序灌成 4、5、6、10 每一個值對應的副本數
三 0 和 1 之間誰在做 把佇列深度降到 0 再升回 1,全程記時間 縮到 0 和拉回 1 各花多久,以及 HPA 在 0 的時候是什麼狀態

建叢集

# kind.yaml
apiVersion: kind.x-k8s.io/v1alpha4
kind: Cluster
name: keda-lab
nodes:
  - role: control-plane
    image: kindest/node:v1.36.1@sha256:3489c7674813ba5d8b1a9977baea8a6e553784dab7b84759d1014dbd78f7ebd5
kind create cluster --config kind.yaml
kubectl wait --for=condition=Ready node/keda-lab-control-plane --timeout=3m
node/keda-lab-control-plane condition met
kubectl get nodes
NAME                     STATUS   ROLES           AGE   VERSION
keda-lab-control-plane   Ready    control-plane   20s   v1.36.1

裝 KEDA

helm repo add kedacore https://kedacore.github.io/charts && helm repo update
helm install keda kedacore/keda --namespace keda --create-namespace \
  --version 2.21.0 --wait --timeout 5m
kubectl get pods -n keda
NAME                                               READY   STATUS    RESTARTS      AGE
keda-admission-webhooks-56b7b7c6c8-m8njt           1/1     Running   0             47s
keda-operator-7bc858d9d8-kdmrw                     1/1     Running   1 (36s ago)   47s
keda-operator-metrics-apiserver-7d67c4b8c8-6dwhk   1/1     Running   0             47s
kubectl -n keda get deploy -o jsonpath='{range .items[*]}{.metadata.name}{"  "}{.spec.template.spec.containers[0].image}{"\n"}{end}'
keda-admission-webhooks  ghcr.io/kedacore/keda-admission-webhooks:2.21.0
keda-operator  ghcr.io/kedacore/keda:2.21.0
keda-operator-metrics-apiserver  ghcr.io/kedacore/keda-metrics-apiserver:2.21.0

假的 vLLM

# 01-fake-vllm.yaml
apiVersion: v1
kind: ConfigMap
metadata: { name: fake-vllm-src }
data:
  server.py: |
    import http.server, urllib.parse
    WAITING = 0.0
    class H(http.server.BaseHTTPRequestHandler):
        def do_GET(self):
            global WAITING
            u = urllib.parse.urlparse(self.path)
            if u.path == "/set":
                q = urllib.parse.parse_qs(u.query)
                WAITING = float(q.get("waiting", ["0"])[0])
                body = f"waiting={WAITING}\n".encode()
            elif u.path == "/metrics":
                body = (
                    "# HELP vllm:num_requests_waiting Number of requests waiting\n"
                    "# TYPE vllm:num_requests_waiting gauge\n"
                    f'vllm:num_requests_waiting{{model_name="fake"}} {WAITING}\n'
                ).encode()
            else:
                self.send_response(404); self.end_headers(); return
            self.send_response(200)
            self.send_header("Content-Type", "text/plain; version=0.0.4")
            self.send_header("Content-Length", str(len(body)))
            self.end_headers(); self.wfile.write(body)
        def log_message(self, *a): pass
    http.server.HTTPServer(("", 8000), H).serve_forever()
---
apiVersion: apps/v1
kind: Deployment
metadata: { name: fake-vllm, labels: { app: fake-vllm } }
spec:
  replicas: 1
  selector: { matchLabels: { app: fake-vllm } }
  template:
    metadata: { labels: { app: fake-vllm } }
    spec:
      containers:
        - name: main
          image: python:3.12-slim
          command: ["python3","/src/server.py"]
          ports: [{ containerPort: 8000, name: metrics }]
          volumeMounts: [{ name: src, mountPath: /src }]
      volumes:
        - { name: src, configMap: { name: fake-vllm-src } }
---
apiVersion: v1
kind: Service
metadata: { name: fake-vllm }
spec:
  selector: { app: fake-vllm }
  ports: [{ name: metrics, port: 8000, targetPort: 8000 }]

最小的 Prometheus

只抓 fake-vllm,不裝 kube-prometheus-stack。

# 02-prometheus.yaml
apiVersion: v1
kind: ConfigMap
metadata: { name: prom-config }
data:
  prometheus.yml: |
    global: { scrape_interval: 5s }
    scrape_configs:
      - job_name: fake-vllm
        static_configs:
          - targets: ["fake-vllm.default.svc.cluster.local:8000"]
---
apiVersion: apps/v1
kind: Deployment
metadata: { name: prometheus, labels: { app: prometheus } }
spec:
  replicas: 1
  selector: { matchLabels: { app: prometheus } }
  template:
    metadata: { labels: { app: prometheus } }
    spec:
      containers:
        - name: prom
          image: prom/prometheus:v3.6.0
          args: ["--config.file=/etc/prom/prometheus.yml","--storage.tsdb.retention.time=1h"]
          ports: [{ containerPort: 9090 }]
          volumeMounts: [{ name: cfg, mountPath: /etc/prom }]
      volumes: [{ name: cfg, configMap: { name: prom-config } }]
---
apiVersion: v1
kind: Service
metadata: { name: prometheus }
spec:
  selector: { app: prometheus }
  ports: [{ port: 9090, targetPort: 9090 }]
kubectl apply -f 01-fake-vllm.yaml -f 02-prometheus.yaml
kubectl wait --for=condition=Available deploy/fake-vllm deploy/prometheus --timeout=3m
deployment.apps/fake-vllm condition met
deployment.apps/prometheus condition met

確認訊號管線通了

kubectl port-forward svc/prometheus 9090:9090 &
# q.sh  灌佇列深度:./q.sh 5   不給參數就只讀現值
#!/bin/bash
if [ -n "$1" ]; then
  kubectl exec deploy/fake-vllm -- python3 -c "
import urllib.request
print(urllib.request.urlopen('http://127.0.0.1:8000/set?waiting=$1').read().decode(), end='')"
fi
printf 'prometheus sum = '
curl -s --get http://127.0.0.1:9090/api/v1/query \
  --data-urlencode 'query=sum(vllm:num_requests_waiting)' \
  | python3 -c "import json,sys; r=json.load(sys.stdin)['data']['result']; print(r[0]['value'][1] if r else 'none')"
./q.sh 7 && sleep 8 && ./q.sh
waiting=7.0
prometheus sum = 0
prometheus sum = 7

灌進去之後要等一個 scrape 週期(5 秒)Prometheus 才看得到,所以第一次查還是 0。測完歸零。

./q.sh 0

被擴縮的目標與 ScaledObject

# 03-scaledobject.yaml
apiVersion: apps/v1
kind: Deployment
metadata: { name: vllm-pool, labels: { app: vllm-pool } }
spec:
  replicas: 0
  selector: { matchLabels: { app: vllm-pool } }
  template:
    metadata: { labels: { app: vllm-pool } }
    spec:
      terminationGracePeriodSeconds: 0
      containers:
        - name: main
          image: registry.k8s.io/pause:3.10
          resources: { requests: { cpu: "10m" } }
---
apiVersion: keda.sh/v1alpha1
kind: ScaledObject
metadata: { name: vllm-pool }
spec:
  scaleTargetRef: { name: vllm-pool }
  pollingInterval: 5            # 預設 30,等不動
  cooldownPeriod: 20            # 預設 300,不改要等五分鐘
  minReplicaCount: 0            # 縮到零
  maxReplicaCount: 10
  triggers:
    - type: prometheus
      metadata:
        serverAddress: http://prometheus.default.svc.cluster.local:9090
        query: 'sum(vllm:num_requests_waiting)'
        threshold: "2"          # 每 2 個排隊請求配一個副本
kubectl apply -f 03-scaledobject.yaml
deployment.apps/vllm-pool created
scaledobject.keda.sh/vllm-pool created

一、裝了 KEDA,真正在縮的是 HPA

kubectl get hpa
NAME                 REFERENCE              TARGETS     MINPODS   MAXPODS   REPLICAS   AGE
keda-hpa-vllm-pool   Deployment/vllm-pool   0/2 (avg)   1         10        1          20s

我只 apply 了 Deployment 和 ScaledObject,這個 HPA 是 KEDA 建出來的。

kubectl get hpa keda-hpa-vllm-pool -o jsonpath='{.metadata.ownerReferences}' | python3 -m json.tool
[
    {
        "apiVersion": "keda.sh/v1alpha1",
        "blockOwnerDeletion": true,
        "controller": true,
        "kind": "ScaledObject",
        "name": "vllm-pool",
        "uid": "1a662f09-ddd9-43dd-b6fa-f3f5dbdd14d1"
    }
]

它的 owner 是 ScaledObject,名字也符合 keda-hpa-{scaled-object-name} 這個規則。

kubectl get hpa keda-hpa-vllm-pool -o jsonpath='{.spec.metrics}' | python3 -m json.tool
[
    {
        "external": {
            "metric": {
                "name": "s0-prometheus",
                "selector": { "matchLabels": { "scaledobject.keda.sh/name": "vllm-pool" } }
            },
            "target": { "averageValue": "2", "type": "AverageValue" }
        },
        "type": "External"
    }
]

s0 是第 0 個 trigger。型別是 External 搭 AverageValue,和 HPA 那節看到的 production-stack 範例(Object 搭 Value)不一樣,但兩種都能用來表達「以佇列深度為依據」。這個組合在實驗二會解釋算式。

這個 metric 不在 Prometheus 裡,HPA 讀不到 Prometheus。它是被註冊成 External Metrics API 的一個供應者:

kubectl get apiservice v1beta1.external.metrics.k8s.io -o jsonpath='{.spec.service}'
{"name":"keda-operator-metrics-apiserver","namespace":"keda","port":443}
kubectl get --raw "/apis/external.metrics.k8s.io/v1beta1/namespaces/default/s0-prometheus?labelSelector=scaledobject.keda.sh/name=vllm-pool" | python3 -m json.tool
{
    "kind": "ExternalMetricValueList",
    "items": [
        { "metricName": "s0-prometheus", "timestamp": "2026-10-04T07:39:33Z", "value": "1" }
    ]
}

所以 KEDA 的位置在 HPA 前面,不是旁邊。它把「怎麼從 Prometheus 拿到數字」這件事包成一個 External Metrics API,實際改副本數的是 HPA。

還有一個數字先放著:MINPODS 是 1,而我在 ScaledObject 裡寫的是 0。那是實驗三的事。


二、副本數的算式

for v in 4 5 6 10; do
  ./q.sh $v >/dev/null
  printf '灌 %-3s → ' "$v"
  for i in $(seq 1 12); do
    sleep 5
    printf '%s ' "$(kubectl get deploy vllm-pool -o jsonpath='{.spec.replicas}')"
  done
  echo
done
灌 4   → 0 1 2 2 2 2 2 2 2 2 2 2
灌 5   → 2 3 3 3 3 3 3 3 3 3 3 3
灌 6   → 3 3 3 3 3 3 3 3 3 3 3 3
灌 10  → 3 5 5 5 5 5 5 5 5 5 5 5

每一格每 5 秒取樣一次,一列共 60 秒。

排隊數 副本數
4 2
5 3
6 3
10 5

關鍵的一列是 5。如果算式是按比例分配,5 除以 2 是 2.5,捨去會得到 2;量到的是 3,所以它是進位。6 那一列也是 3,不過那一列是從 3 開始、停在 3,分不出是重新算出 3 還是本來就在 3 沒動,只能當佐證。

所以算式約等於 ceil(排隊數 ÷ threshold)。寫約等於是因為 HPA 還有一個容忍度,差距太小的時候它不會動,寫等號不夠安全。

這是 HPA 的算術,不是 KEDA 的。原因在 AverageValue 這個目標型別:HPA 看到的不是總數,而是平均每個副本分到多少。下面這個讀數是實驗三回程時拍的,當時排隊數 1、副本數 5:

kubectl get hpa keda-hpa-vllm-pool -o jsonpath='{.status.currentMetrics}' | python3 -m json.tool
averageValue: 200m

200m 就是 0.2,也就是 1 除以 5。HPA 拿這個平均值去比目標值 2,結果等價於拿總數除以 2 再進位。

另外 maxReplicaCount 我設 10 而不是 5。如果設 5,最後一列的 5 個副本會同時符合「算出來剛好 5」和「想要更多但不准」兩種解釋,分不出是哪一個。


三、0 和 1 之間是 KEDA 自己做的

去程:5 降到 0

./q.sh 0

t0=$(date +%s)
while :; do
  printf '%4ds  replicas=%-3s ACTIVE=%s\n' "$(( $(date +%s)-t0 ))" \
    "$(kubectl get deploy vllm-pool -o jsonpath='{.spec.replicas}')" \
    "$(kubectl get scaledobject vllm-pool -o jsonpath='{.status.conditions[?(@.type=="Active")].status}')"
  [ "$(kubectl get deploy vllm-pool -o jsonpath='{.spec.replicas}')" = "0" ] && break
  sleep 10
done
   0s  replicas=5   ACTIVE=True
  11s  replicas=5   ACTIVE=False
  21s  replicas=5   ACTIVE=False
  31s  replicas=0   ACTIVE=False

trigger 在 11 秒變成 inactive,再過 20 秒副本數從 5 直接掉到 0,中間沒有經過 1。

這 31 秒拆開來是 11 秒偵測加上 20 秒的 cooldownPeriod,而 20 是我改過的值,預設是 300。照預設跑的話這一步要五分多鐘。

那三個數字

printf 'ScaledObject.minReplicaCount = %s\n' "$(kubectl get scaledobject vllm-pool -o jsonpath='{.spec.minReplicaCount}')"
printf 'HPA.spec.minReplicas         = %s\n' "$(kubectl get hpa keda-hpa-vllm-pool -o jsonpath='{.spec.minReplicas}')"
printf 'Deployment 實際副本數        = %s\n' "$(kubectl get deploy vllm-pool -o jsonpath='{.spec.replicas}')"
ScaledObject.minReplicaCount = 0
HPA.spec.minReplicas         = 1
Deployment 實際副本數        = 0

我寫 0,KEDA 給 HPA 的是 1,實際副本數是 0。

kubectl get hpa
NAME                 REFERENCE              TARGETS             MINPODS   MAXPODS   REPLICAS   AGE
keda-hpa-vllm-pool   Deployment/vllm-pool   <unknown>/2 (avg)   1         10        0          6m2s

HPA 還在,但副本數是 0 的時候它算不出東西。

kubectl get scaledobject vllm-pool -o jsonpath='{range .status.conditions[*]}{.type}={.status}  reason={.reason}  msg={.message}{"\n"}{end}'
Ready=True       reason=ScaledObjectReady  msg=ScaledObject is defined correctly and is ready for scaling
Active=False     reason=ScalerNotActive    msg=Scaling is not performed because triggers are not active
Fallback=False   reason=NoFallbackFound    msg=No fallbacks are active on this scaled object
Paused=False
HPAActive=True   reason=ScalingDisabled    msg=scaling is disabled since the replica count of the target is zero

最後一行把這件事講完了:目標副本數是 0 的時候 HPA 的擴縮是停用的。所以把它從 0 拉回 1 的只能是 KEDA。

回程:0 回到 1

./q.sh 1
  0s  replicas=0   ACTIVE=False
 11s  replicas=1   ACTIVE=True
 21s  replicas=4
 36s  replicas=5

KEDA 在 11 秒把 0 拉到 1。trigger 一有活動,KEDA 就立刻把目標資源擴到 minReplicaCount,之後的擴縮交給 HPA。

HPA 算過的建議值跨過了零

接手之後 HPA 做的事情很意外:它把副本數推到 5,而當時排隊數只有 1。

kubectl get hpa keda-hpa-vllm-pool -o jsonpath='{.status.conditions[?(@.type=="AbleToScale")]}' | python3 -m json.tool
reason  = ScaleDownStabilized
message = recent recommendations were higher than current one,
          applying the highest recent recommendation

原因是 HPA 縮容前那 300 秒的回頭看。排隊數還是 10 的時候 HPA 算過 5 這個建議值,而它取的是這 300 秒裡最大的那一個。副本數歸零那段時間並沒有把這些舊建議值清掉,所以 KEDA 一把它拉回 1,HPA 立刻拿五分鐘前的舊值把它推回 5。

接著看它什麼時候才願意降下來。

t0=$(date +%s)
while :; do
  printf '%4ds  replicas=%-3s AbleToScale=%s\n' "$(( $(date +%s)-t0 ))" \
    "$(kubectl get deploy vllm-pool -o jsonpath='{.spec.replicas}')" \
    "$(kubectl get hpa keda-hpa-vllm-pool -o jsonpath='{.status.conditions[?(@.type=="AbleToScale")].reason}')"
  [ "$(kubectl get deploy vllm-pool -o jsonpath='{.spec.replicas}')" = "1" ] && break
  sleep 20
done
   1s  replicas=5   AbleToScale=ScaleDownStabilized
  21s  replicas=5   AbleToScale=ScaleDownStabilized
  41s  replicas=5   AbleToScale=ScaleDownStabilized
  61s  replicas=5   AbleToScale=ScaleDownStabilized
  81s  replicas=5   AbleToScale=ScaleDownStabilized
 101s  replicas=1   AbleToScale=SucceededRescale

101 秒不是回頭看的長度。回頭看固定是 300 秒,而 101 秒是那些比 1 還高的舊建議值全部超過 300 秒、不再被算進去所需的時間。它取決於之前算過哪些建議值、什麼時候算的,換一次實驗就會是另一個數字。


四個方向,誰做,多久

方向 誰做 實測
0 到 1 KEDA 11 秒
1 到 N HPA 5 到 15 秒
N 到 M,兩邊都大於 0 HPA 101 秒
N 到 0 KEDA 31 秒

從 5 降到 0 只要 31 秒,從 5 降到 1 卻要 101 秒。整組關掉比留一個還快,因為降到 0 是 KEDA 做的,降到 1 是 HPA 做的,而 HPA 縮容前要先回頭看過去 300 秒算過的建議值。

另外兩列的數字跟設定有關。1 到 N 的 5 到 15 秒,對得上 HPA 的 --horizontal-pod-autoscaler-sync-period 預設值 15 秒。N 到 0 的 31 秒是我把 cooldownPeriod 從 300 改成 20 的結果,照預設會是五分多鐘。

收尾。

kind delete cluster --name keda-lab

其他擴縮方案

除了 HPA 和 KEDA,編排層自己也有擴縮器。以下四個今天都沒有測,只是記錄它們的定位。

名稱 定位
Knative Pod Autoscaler Knative Serving 核心的一部分,Knative Serving 裝好之後預設就啟用,預設的 autoscaler class 是 kpa.autoscaling.knative.dev。它支援縮到零,但不支援以 CPU 為依據的擴縮。Knative 裡另一個選項是 HPA,那個不支援縮到零但支援 CPU
Workload Variant Autoscaler llm-d 原本自己做的擴縮 controller,現在已經廢棄。llm-d 目前的擴縮是 KEDA 直接讀 Prometheus 上的推論指標,再透過它管理的那個 HPA 去縮放模型伺服器的 Deployment,這條路上沒有任何自製的 controller
Dynamo Planner NVIDIA Dynamo 的元件,用引擎效能模型加上流量預測分別調整 prefill 和 decode 的副本數,瞄準 TTFT 與 ITL 的 SLA。load-based 模式讀 ForwardPassMetrics,預設每 5 秒調整一次;SLA-based 模式以 180 秒為區間做流量預測
DisaggregatedSetRoleScaler LWS 專案的 CRD,跟 Day 19 的 DisaggregatedSet 一起裝。它為每一個 role 提供一個穩定的 /scale 目標,讓 role 跨過滾動更新之後還能被外部擴縮。要在 role 上寫 scaling.mode: External 才會啟用,啟用之後 DisaggregatedSet controller 會自動建一個名為 <disaggregatedset 名稱>-<role 名稱> 的 DisaggregatedSetRoleScaler

這幾個的共同點是都綁在特定的框架上:要用 KPA 得先有整套 Knative Serving,Planner 屬於 Dynamo,role scaler 屬於 DisaggregatedSet。HPA 和 KEDA 不綁框架,而 llm-d 是自己走到這個結論的:先做了一個專屬的 controller,最後砍掉換成 KEDA。


小結

今天的重點是分工。裝了 KEDA 之後,真正在改副本數的還是一個 HPA;KEDA 負責把 Prometheus 的數字變成 Kubernetes 的指標 API,以及 0 和 1 之間那一段。1 以上全是 HPA 的算術和 HPA 的節奏。

四個方向的時間差都能用這條線解釋。降到 0 只要 31 秒、降到 1 要 101 秒,不是因為降到 0 比較簡單,而是因為前者不經過 HPA。


參考資料


上一篇
【Day 19】Kubernetes LeaderWorkerSet 與 DisaggregatedSet
系列文
凌晨四點,女友帶著 GPU 來我家學習 Kubernetes:打造 K8s AI Infra 的 30 夜 共 20 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言